// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.server.fixes;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.jgit.diff.ReplaceEdit;
import com.google.gerrit.server.patch.IntraLineLoader;
import com.google.gerrit.server.patch.Text;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.eclipse.jgit.diff.Edit;

/**
 * Produces final version of an input content with all fixes applied together with list of edits.
 */
public class FixCalculator {
  private static final Comparator<FixReplacement> ASC_RANGE_FIX_REPLACEMENT_COMPARATOR =
      Comparator.comparing(fixReplacement -> fixReplacement.range);

  private FixCalculator() {}

  /**
   * Returns a result of applying fixes to an original content.
   *
   * @param originalContent is a text to which fixes must be applied
   * @param fixReplacements is a list of fixes to be applied
   * @throws ResourceConflictException if the fixReplacements contains invalid data (for example, if
   *     an item points to an invalid range or if some ranges are intersected).
   */
  public static String getNewFileContent(
      String originalContent, List<FixReplacement> fixReplacements)
      throws ResourceConflictException {
    FixResult fixResult =
        calculateFix(new Text(originalContent.getBytes(UTF_8)), fixReplacements, false);
    return fixResult.text.getString(0, fixResult.text.size(), false);
  }

  /**
   * Returns a result of applying fixes to an original content and list of applied edits.
   *
   * @param originalText is a text to which fixes must be applied
   * @param fixReplacements is a list of fixes to be applied
   * @return {@link FixResult}
   * @throws ResourceConflictException if the fixReplacements contains invalid data (for example, if
   *     an item points to an invalid range or if some ranges are intersected).
   */
  public static FixResult calculateFix(
      Text originalText, List<FixReplacement> fixReplacements, boolean intraline)
      throws ResourceConflictException {
    List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
    sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
    if (!sortedReplacements.isEmpty() && sortedReplacements.get(0).range.startLine <= 0) {
      throw new ResourceConflictException(
          String.format(
              "Cannot calculate fix replacement for range %s",
              toString(sortedReplacements.get(0).range)));
    }
    ContentBuilder builder = new ContentBuilder(originalText, intraline);
    for (FixReplacement fixReplacement : sortedReplacements) {
      try {
        builder.addReplacement(fixReplacement);
      } catch (IndexOutOfBoundsException e) {
        throw new ResourceConflictException(
            String.format(
                "Cannot calculate fix replacement for range %s", toString(fixReplacement.range)),
            e);
      }
    }
    return builder.build();
  }

  private static String toString(Comment.Range range) {
    return String.format(
        "(%s:%s - %s:%s)", range.startLine, range.startChar, range.endLine, range.endChar);
  }

  /*
  Algorithm:
  Input:
    Original text (aka srcText)
    Sorted list of replacements in ascending order, where each replacement has:
        srcRange - part of the original text to be
                   replaced, inserted or deleted (see {@link Comment.Range} for details)
        replacement - text to be set instead of srcRange
    Replacement ranges must not intersect.

  Output:
    Final text (aka finalText)
    List of Edit, where each Edit is an instance of {@link ReplaceEdit}
      Each ReplaceEdit cover one or more lines in the original text
      Each ReplaceEdit contains one or more Edit for intraline edits
    See {@link ReplaceEdit} and {@link Edit} for details.
  *
  Note: The algorithm is implemented in this way to avoid string.replace operations.
  It has complexity O(len(replacements) + max(len(originalText), len(finalText)) )

  Main steps:
  - set srcPos to start of the original text. It is like a cursor position in the original text.
  - set dstPos to start of the final text.  It is like a cursor position in the final text.
  - the finalText initially empty

  - for each replacement:
       - append text between a previous and a current replacement to the finalText
           (because replacements were sorted, this part of text can't be changed by
             following replacements). I.e. append substring of srcText between srcPos
             and replacement.srcRange.start to the finalText
           Update srcPos and dstPos - set them at the end of appended text
           (i.e. srcPos points to the position before replacement.srcRange.start,
            dstPos points to the position where replacement.text should be inserted)
       - set dstReplacementStart = dstPos
       - append replacement.text to the finalText.
           Update srcPos and dstPos accordingly (i.e. srcPos points to the position after
           replacement.srcRange, dstPos points to the position in the finalText after
           the appended replacement.text).
       - set dstReplacementEnd = dstPos
       - dstRange = (dstReplacementStart, dstReplacementEnd) - is the range in the finalText.
       - srcRange = (replacement.Start, replacement.End) -  is the range in the original text *

       - If previously created ReplaceEdit ends on the same or previous line as srcRange.startLine,
           then intraline edit is added to it (and ReplaceEdit endLine must be updated if needed);
           srcRange and dstRange together is used to calculate intraline Edit
         otherwise
          create new ReplaceEdit and add intraline Edit to it
          srcRange and dstRange together is used to calculate intraline Edit

  - append text after the last replacements,
      i.e. add part of srcText after srcPos to the finalText

  - Return the finalText and all created ReplaceEdits

  Implementation notes:
  1) The intraline Edits inside ReplaceEdit stores positions relative to ReplaceEdit start.
  2) srcPos and dstPos tracks current position as 3 numbers:
  - line number
  - column number
  - textPos - absolute position from the start of the text. The textPos is used to calculate
  relative positions of Edit inside ReplaceEdit
     */
  private static class ContentBuilder {
    private static class FixRegion {
      int startSrcLine;
      int startDstLine;
      int startSrcPos;
      int startDstPos;
      List<Edit> internalEdits;

      FixRegion() {
        this.internalEdits = new ArrayList<>();
      }
    }

    private final ContentProcessor contentProcessor;
    private final Text src;
    private final boolean intraline;
    final ImmutableList.Builder<Edit> edits;
    FixRegion currentRegion;

    ContentBuilder(Text src, boolean intraline) {
      this.contentProcessor = new ContentProcessor(src);
      this.src = src;
      this.edits = new ImmutableList.Builder<>();
      this.intraline = intraline;
    }

    void addReplacement(FixReplacement replacement) {
      if (shouldStartNewEdit(replacement)) {
        finishExistingEdit();
      }
      // processSrcContent expects that line number is 0-based,
      // but replacement.range.startLine is 1-based, so subtract 1
      processSrcContent(replacement.range.startLine - 1, replacement.range.startChar, true);
      processReplacement(replacement);
    }

    Text getNewText() {
      return new Text(contentProcessor.sb.toString().getBytes(UTF_8));
    }

    void finish() {
      finishExistingEdit();
      if (contentProcessor.hasMoreLines()) {
        contentProcessor.appendLinesToEndOfContent();
      }
    }

    private ImmutableList<Edit> buildEdits() {
      if (this.intraline) {
        return IntraLineLoader.compute(
                this.src, this.getNewText(), edits.build(), ImmutableSet.of())
            .getEdits();
      }
      return edits.build();
    }

    public FixResult build() {
      finish();
      return new FixResult(this.buildEdits(), this.getNewText());
    }

    private void finishExistingEdit() {
      if (contentProcessor.srcPosition.column > 0 || contentProcessor.dstPosition.column > 0) {
        contentProcessor.processToEndOfLine(true);
      }
      if (currentRegion != null) {
        int endSrc = contentProcessor.srcPosition.line;
        if (contentProcessor.srcPosition.column > 0) {
          endSrc++;
        }
        int endDst = contentProcessor.dstPosition.line;
        if (contentProcessor.dstPosition.column > 0) {
          endDst++;
        }
        ReplaceEdit edit =
            new ReplaceEdit(
                currentRegion.startSrcLine,
                endSrc,
                currentRegion.startDstLine,
                endDst,
                currentRegion.internalEdits);
        currentRegion = null;
        edits.add(edit);
      }
    }

    private boolean shouldStartNewEdit(FixReplacement replacement) {
      if (currentRegion == null) {
        return true;
      }
      // New edit must be started if there is at least one unchanged line after the last edit
      // Subtract 1 from replacement.range.startLine because it is a 1-based line number,
      // and contentProcessor.srcPosition.line is a 0-based line number
      return replacement.range.startLine - 1 > contentProcessor.srcPosition.line + 1;
    }

    private void processSrcContent(int toLine, int toColumn, boolean append)
        throws IndexOutOfBoundsException {
      // toLine >= currentSrcLineIndex
      if (toLine == contentProcessor.srcPosition.line) {
        contentProcessor.processLineToColumn(toColumn, append);
      } else {
        contentProcessor.processToEndOfLine(append);
        contentProcessor.processMultiline(toLine, append);
        if (contentProcessor.endOfSource
            && toLine == contentProcessor.srcPosition.line + 1
            && toColumn == 0) {
          // There is a special case, when the file doesn't have EOL mark at the end.
          // The end of a fix replacement range can be expressed in 2 ways:
          // 1) Line number = the last line number, column = the length of the last line (i.e. a
          // character after the end of the string)
          // Or
          // 2) Line number = the last line number + 1, column = 0  (so it points to a non-existing
          // line after the last line).
          // This should be treated as a valid case, but processLineToColumn shouldn't be called
          // (otherwise it throws an exceptions).
          return;
        }
        contentProcessor.processLineToColumn(toColumn, append);
      }
    }

    private void processReplacement(FixReplacement fix) {
      if (currentRegion == null) {
        currentRegion = new FixRegion();
        currentRegion.startSrcLine = contentProcessor.srcPosition.line;
        currentRegion.startSrcPos = contentProcessor.srcPosition.getLineStartPos();
        currentRegion.startDstLine = contentProcessor.dstPosition.line;
        currentRegion.startDstPos = contentProcessor.dstPosition.getLineStartPos();
      }
      int srcStartPos = contentProcessor.srcPosition.textPos;
      int dstStartPos = contentProcessor.dstPosition.textPos;
      contentProcessor.appendReplacement(fix.replacement);
      processSrcContent(fix.range.endLine - 1, fix.range.endChar, false);

      currentRegion.internalEdits.add(
          new Edit(
              srcStartPos - currentRegion.startSrcPos,
              contentProcessor.srcPosition.textPos - currentRegion.startSrcPos,
              dstStartPos - currentRegion.startDstPos,
              contentProcessor.dstPosition.textPos - currentRegion.startDstPos));
    }
  }

  private static class ContentProcessor {
    static class ContentPosition {
      int line;
      int column;
      int textPos;

      void appendMultilineContent(int lineCount, int charCount) {
        line += lineCount;
        column = 0;
        textPos += charCount;
      }

      void appendLineEndedWithEOLMark(int charCount) {
        textPos += charCount;
        line++;
        column = 0;
      }

      void appendStringWithoutEOLMark(int charCount) {
        textPos += charCount;
        column += charCount;
      }

      int getLineStartPos() {
        return textPos - column;
      }
    }

    private final StringBuilder sb;
    final ContentPosition srcPosition;
    final ContentPosition dstPosition;
    String currentSrcLine;
    Text src;
    boolean endOfSource;

    ContentProcessor(Text src) {
      this.src = src;
      sb = new StringBuilder(src.size());
      srcPosition = new ContentPosition();
      dstPosition = new ContentPosition();
      endOfSource = src.size() == 0;
    }

    void processMultiline(int toLine, boolean append) {
      if (endOfSource || toLine <= srcPosition.line) {
        return;
      }
      int fromLine = srcPosition.line;
      String lines = src.getString(fromLine, toLine, false);
      int lineCount = toLine - fromLine;
      int charCount = lines.length();
      srcPosition.appendMultilineContent(lineCount, charCount);

      if (append) {
        sb.append(lines);
        dstPosition.appendMultilineContent(lineCount, charCount);
      }
      currentSrcLine = null;
      endOfSource = srcPosition.line >= src.size();
    }

    void processToEndOfLine(boolean append) {
      if (endOfSource) {
        return;
      }
      String srcLine = getCurrentSrcLine();
      int from = srcPosition.column;
      int charCount = srcLine.length() - from;
      boolean lastLineNoEOLMark = srcPosition.line >= src.size() - 1 && src.isMissingNewlineAtEnd();
      if (!lastLineNoEOLMark) {
        srcPosition.appendLineEndedWithEOLMark(charCount);
        endOfSource = srcPosition.line >= src.size();
      } else {
        srcPosition.appendStringWithoutEOLMark(charCount);
        endOfSource = true;
      }
      if (append) {
        sb.append(srcLine, from, srcLine.length());
        if (!lastLineNoEOLMark) {
          dstPosition.appendLineEndedWithEOLMark(charCount);
        } else {
          dstPosition.appendStringWithoutEOLMark(charCount);
        }
      }
      currentSrcLine = null;
    }

    void processLineToColumn(int to, boolean append) throws IndexOutOfBoundsException {
      int from = srcPosition.column;
      if (from > to) {
        throw new IndexOutOfBoundsException(
            String.format("The parameter from is greater than to. from: %d, to: %d", from, to));
      }
      if (to == 0) {
        return;
      }
      String srcLine = getCurrentSrcLine();
      if (to > srcLine.length()) {
        throw new IndexOutOfBoundsException("Parameter to is out of string");
      } else if (to == srcLine.length()) {
        if (srcPosition.line < src.size() - 1 || !src.isMissingNewlineAtEnd()) {
          throw new IndexOutOfBoundsException("The processLineToColumn shouldn't add end of line");
        }
      }
      int charCount = to - from;
      srcPosition.appendStringWithoutEOLMark(charCount);
      if (append) {
        sb.append(srcLine, from, to);
        dstPosition.appendStringWithoutEOLMark(charCount);
      }
    }

    void appendLinesToEndOfContent() {
      processMultiline(src.size(), true);
    }

    void appendReplacement(String replacement) {
      if (replacement.length() == 0) {
        return;
      }
      sb.append(replacement);
      int lastNewLinePos = -1;
      int newLineMarkCount = 0;
      while (true) {
        int index = replacement.indexOf('\n', lastNewLinePos + 1);
        if (index < 0) {
          break;
        }
        lastNewLinePos = index;
        newLineMarkCount++;
      }
      if (newLineMarkCount > 0) {
        dstPosition.appendMultilineContent(newLineMarkCount, lastNewLinePos + 1);
      }
      dstPosition.appendStringWithoutEOLMark(replacement.length() - lastNewLinePos - 1);
    }

    boolean hasMoreLines() {
      return !endOfSource;
    }

    private String getCurrentSrcLine() {
      if (currentSrcLine == null) {
        currentSrcLine = src.getString(srcPosition.line, srcPosition.line + 1, false);
      }
      return currentSrcLine;
    }
  }

  /** The result of applying fix to a file content */
  public static class FixResult {
    /** List of edits to transform an original text to a final text (with all fixes applied) */
    public final ImmutableList<Edit> edits;

    /** Final text with all applied fixes */
    public final Text text;

    FixResult(ImmutableList<Edit> edits, Text text) {
      this.edits = edits;
      this.text = text;
    }
  }
}
